Skip to content

feat: add upgrade method to WormholeExecutor contract#3603

Open
jayantk wants to merge 2 commits intostellarfrom
hydra/i-vlooou/head
Open

feat: add upgrade method to WormholeExecutor contract#3603
jayantk wants to merge 2 commits intostellarfrom
hydra/i-vlooou/head

Conversation

@jayantk
Copy link
Copy Markdown
Contributor

@jayantk jayantk commented Apr 8, 2026

Add self-upgrade capability to the wormhole-executor-stellar contract, matching the pattern used by pyth-lazer-stellar. The upgrade method requires auth from the contract's own address, enabling self-governed upgrades via governance VAAs dispatched through execute_governance_action with target_contract set to the executor itself.

Changes:

  • Added upgrade(env, new_wasm_hash) method to WormholeExecutor in lib.rs
  • Added unit test: test_upgrade_authorized (auth passes, fails at deployer level with fake hash)
  • Added unit test: test_upgrade_unauthorized (panics with auth error)
  • Added integration test: test_governance_upgrade_dispatched_to_executor (full self-upgrade governance flow)

All existing tests continue to pass (57 unit tests + 19 integration tests).

Add self-upgrade capability to the wormhole-executor-stellar contract,
matching the pattern used by pyth-lazer-stellar. The upgrade method
requires auth from the contract's own address, enabling self-governed
upgrades via governance VAAs dispatched through execute_governance_action
with target_contract set to the executor itself.

Includes unit tests (authorized + unauthorized upgrade attempts) and an
integration test for the full self-upgrade governance dispatch flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jayantk jayantk requested a review from a team as a code owner April 8, 2026 07:34
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api-reference Ready Ready Preview, Comment Apr 16, 2026 2:00pm
component-library Ready Ready Preview, Comment Apr 16, 2026 2:00pm
developer-hub Error Error Apr 16, 2026 2:00pm
entropy-explorer Error Error Apr 16, 2026 2:00pm
insights Error Error Apr 16, 2026 2:00pm
proposals Ready Ready Preview, Comment Apr 16, 2026 2:00pm
staking Ready Ready Preview, Comment Apr 16, 2026 2:00pm

Request Review

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 7 additional findings in Devin Review.

Open in Devin Review

Comment on lines +65 to +71
pub fn update_guardian_set(env: Env, vaa_bytes: Bytes) -> Result<(), ContractError> {
guardian::require_initialized(&env)?;
guardian::extend_instance_ttl(&env);

// Parse and verify the VAA with current guardian set.
let vaa = parse_vaa(&env, &vaa_bytes)?;
verify_vaa(&env, &vaa)?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Missing emitter chain/address validation in update_guardian_set deviates from Wormhole security model

The update_guardian_set function verifies the VAA's guardian signatures and checks the "Core" module payload, but does not validate the VAA's emitter_chain or emitter_address. The Wormhole EVM reference implementation (Governance.sol:136-164) validates both via verifyGovernanceVM, which checks vm.emitterChainId == governanceChainId() and vm.emitterAddress == governanceContract(). This check ensures that only VAAs emitted by the canonical Wormhole governance source (chain 1, address 0x0000...0004) can trigger guardian set upgrades.

Without this check, any VAA signed by the guardian quorum whose payload happens to match the Core module + action 2 + correct target chain + correct next index could be used to hijack the guardian set. While practical exploitability is extremely low (the guardian signatures + "Core" module identifier + sequential index provide strong protection), this is a deviation from the standard Wormhole security model that every other implementation enforces.

EVM reference implementation comparison

The EVM verifyGovernanceVM at target_chains/ethereum/contracts/contracts/wormhole-receiver/ReceiverGovernance.sol:59-86 checks:

  • vm.emitterChainId == governanceChainId() (line 74)
  • vm.emitterAddress == governanceContract() (line 77)
  • governanceActionIsConsumed(vm.hash) (line 82)

The Stellar executor stores owner_emitter_chain and owner_emitter_address for Pyth governance, but these are different from the Wormhole governance emitter (chain=1, address=0x0000...0004). The contract would need to store the Wormhole governance emitter parameters separately and validate them during guardian set upgrades.

Prompt for agents
The update_guardian_set function in wormhole-executor-stellar/src/lib.rs (line 65-143) is missing emitter chain and emitter address validation for guardian set upgrade VAAs. The Wormhole EVM reference implementation (Governance.sol verifyGovernanceVM) validates that guardian set upgrade VAAs come from the canonical Wormhole governance emitter (chain 1, address 0x0000...0004).

To fix this:
1. Add two new storage keys in guardian.rs DataKey enum: WormholeGovernanceChain (u32) and WormholeGovernanceAddress (BytesN<32>)
2. Accept these parameters in the initialize function and store them
3. In update_guardian_set (lib.rs), after verify_vaa succeeds, add checks:
   - vaa.body.emitter_chain as u32 must equal the stored WormholeGovernanceChain
   - vaa.body.emitter_address must equal the stored WormholeGovernanceAddress
4. The Wormhole mainnet governance emitter is on Solana (chain 1) at address 0x0000000000000000000000000000000000000000000000000000000000000004

Alternatively, these could be hardcoded as constants since the Wormhole governance emitter is well-known and stable, but storing them allows flexibility for testnet deployments with different governance emitters.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +25 to +39
pub fn initialize(
env: Env,
executor: Address,
initial_signer: Option<BytesN<33>>,
initial_signer_expires_at: Option<u64>,
) -> Result<(), ContractError> {
if state::has_executor(&env) {
return Err(ContractError::AlreadyInitialized);
}
state::set_executor(&env, &executor);
if let (Some(pubkey), Some(expires_at)) = (initial_signer, initial_signer_expires_at) {
state::set_trusted_signer(&env, &pubkey, expires_at);
}
state::extend_instance_ttl(&env);
Ok(())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 No authentication on initialize functions allows front-running

Neither PythLazerContract::initialize (pyth-lazer-stellar/src/lib.rs:25-39) nor WormholeExecutor::initialize (wormhole-executor-stellar/src/lib.rs:32-48) require any authentication. This means anyone can call initialize before the deployer if the contract is deployed and initialized in separate transactions. In practice, the deploy script (scripts/deploy.sh) deploys and initializes in sequential CLI commands, so there is a small window for front-running on public networks. This follows the standard Soroban pattern (most Soroban contracts use first-caller-wins initialization), and the AlreadyInitialized guard prevents re-initialization. Still, consider using stellar contract deploy --init or bundling deploy+init in a single transaction for production mainnet deployments.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


# Defaults
NETWORK="testnet"
CHAIN_ID=28
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Wormhole chain ID inconsistency between deploy script (28) and README/tests (30)

The deploy script at scripts/deploy.sh:29 defaults CHAIN_ID=28, while the README (README.md:195) documents chain_id = 30 and all test code (integration-tests/src/test.rs:159, wormhole-executor-stellar/src/test.rs:136) uses chain ID 30. The DESIGN.md acknowledges this is unresolved: 'Need to determine the correct Wormhole chain ID for Stellar.' If the chain ID is wrong at deployment, governance PTGMs targeting the correct chain would be rejected by parse_ptgm's target chain validation. This should be reconciled before mainnet deployment.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@jayantk jayantk changed the base branch from main to stellar April 16, 2026 13:39
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 9 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 UpgradePayload.version field is parsed but never used

The UpgradePayload struct at governance.rs:36-39 includes a version: u64 field that is parsed from the PTGM payload, but execute_governance_action at lib.rs:199-206 only uses payload.wasm_digest and silently discards payload.version. This means an upgrade governance message could specify any version number and it would have no effect. If version is intended to enforce monotonic upgrades or serve as a safety check (e.g., preventing accidental downgrades), it should be validated. If it's purely informational/reserved for future use, this is fine but should be documented.

(Refers to lines 199-207)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 target_contract parameter in execute_governance_action is caller-chosen, not PTGM-bound

The target_contract parameter in execute_governance_action at lib.rs:154 is provided by the transaction caller and is not encoded in the PTGM governance payload. This means a valid governance VAA with an upgrade action could be dispatched to any contract that trusts the executor (e.g., the Lazer contract, the executor itself, or any future contract). The target contract's own auth check (executor auth or self-auth) limits the blast radius, but the governance authority's intent about which contract to upgrade is not cryptographically bound. This is a pre-existing design pattern, not introduced by this PR.

(Refers to lines 151-154)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@jayantk
Copy link
Copy Markdown
Contributor Author

jayantk commented Apr 16, 2026

the tests here are bad. We should actually set up enough mocking so that the upgrade path succeeds in the cases when it should, and we should test that it errors when it should fail (which we aren't doing!)

- Unit test test_upgrade_authorized now uploads real optimized WASM and
  verifies upgrade succeeds (was only asserting failure before)
- Unit test test_upgrade_unauthorized uses try_upgrade instead of
  should_panic for cleaner error assertion
- Added test_upgrade_invalid_wasm_hash to verify bad hash is rejected
- Integration test test_governance_upgrade_dispatched_to_lazer now
  succeeds end-to-end with real WASM hash
- Integration test test_governance_upgrade_dispatched_to_executor
  documents Soroban re-entry limitation for self-upgrade path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 10 additional findings in Devin Review.

Open in Devin Review

Comment on lines +213 to +218
/// Upgrade the contract WASM. Callable only by the contract itself.
pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), ContractError> {
env.current_contract_address().require_auth();
env.deployer().update_current_contract_wasm(new_wasm_hash);
Ok(())
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Executor's upgrade function is unreachable — contract can never be upgraded

The new upgrade function uses env.current_contract_address().require_auth(), meaning only the executor contract itself can authorize the call. However, the only governance path to trigger this is execute_governance_action, which dispatches via env.invoke_contract(&target_contract, "upgrade", args) at lib.rs:202-206. When target_contract is the executor's own address, this creates a re-entrant call, which Soroban explicitly blocks. No external caller can satisfy current_contract_address().require_auth() either, since only the contract itself can provide that authorization. The result is that the executor contract is permanently non-upgradeable.

The integration test test_governance_upgrade_dispatched_to_executor at integration-tests/src/test.rs:718-738 confirms this fails due to re-entry. By contrast, the Lazer contract's upgrade (pyth-lazer-stellar/src/lib.rs:66-71) uses executor.require_auth(), which works because the executor is an external caller.

Possible fix approach In `execute_governance_action`, when `target_contract == env.current_contract_address()` and the action is `Upgrade`, call `env.deployer().update_current_contract_wasm(wasm_hash)` directly instead of going through `invoke_contract`, thereby avoiding re-entry. The `upgrade` function can remain for completeness but the self-upgrade governance path needs the direct call.
Prompt for agents
The executor's upgrade function at lib.rs:213-218 uses env.current_contract_address().require_auth(), which makes it unreachable in practice. The only governance dispatch path is execute_governance_action (lib.rs:199-207), which calls env.invoke_contract on the target. When target is the executor itself, Soroban blocks the re-entrant call.

To fix this, modify the Upgrade arm in execute_governance_action (around lib.rs:199-207) to check if target_contract equals env.current_contract_address(). If it does, call env.deployer().update_current_contract_wasm(wasm_hash) directly. Otherwise, dispatch via invoke_contract as before. This avoids re-entry for self-upgrades while keeping the external dispatch for other contracts like Lazer.

Alternative approach: keep the upgrade function but change auth to use a stored governance authority instead of current_contract_address, similar to how pyth-lazer-stellar/src/lib.rs:66-71 uses executor.require_auth().
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant